查看原文
其他

Android精准测试探索:测试覆盖率统计

叮咚 酷家乐技术质量 2022-11-08

背景

随着业务与需求的增长, 回归测试的范围越来越大,测试人员的压力也日益增加。但即使通过测试同学的保障,线上仍然会存在回归不到位或测试遗漏的地方导致出现线上故障。

因此我们需要通过类似jacoco的集成测试覆盖率统计框架,来衡量测试人员的回归范围是否精准、测试场景是否遗漏;保障上线的代码都已经经过测试人员验证。针对这一点,我们提出了Android测试覆盖率统计工具, 借此来提升测试人员精准测试的能力,借助覆盖率数据补充测试遗漏的测试用例。

工具选型

Android APP开发主流语言就是Java语言,而Java常用覆盖率工具为Jacoco、Emma和Cobertura。

根据上图的一些特点,我们选择jacoco作为测试覆盖率统计工具。

技术选型

众所周知, 获取覆盖率数据的前提条件是需要完成代码的插桩工作。而针对字节码的插桩方式,可分为两种 —— 1、On-The-Fly 2、Offliine

On-The-Fly在线插桩

  • JVM中通过-javaagent参数指定特定的jar文件启动Instrumentation的代理程序

  • 代理程序在每装载一个class文件前判断是否已经转换修改了该文件,如果没有则需要将探针插入class文件中。

  • 代码覆盖率就可以在JVM执行代码的时候实时获取

优点:无需提前进行字节码插桩,无需考虑classpath 的设置。测试覆盖率分析可以在JVM执行测试代码的过程中完成

Offliine离线插桩

  • 在测试之前先对字节码进行插桩,生成插过桩的class文件或者jar包,执行插过桩的class文件或者jar包之后,会生成覆盖率信息到文件,最后统一对覆盖率信息进行处理,并生成报告。

Offlline模式适用于以下场景:

  • 运行环境不支持java agent,部署环境不允许设置JVM参数

  • 字节码需要被转换成其他虚拟机字节码,如Android Dalvik VM 动态修改字节码过程中和其他agent冲突

  • 无法自定义用户加载类。

Android项目只能使用JaCoCo的离线插桩方式。为什么呢?一般运行在服务器java程序的插桩可以在加载class文件进行,运用java Agent的机制,可以理解成"实时插桩"。但是因为Android覆盖率的特殊性,导致 Android系统破坏了JaCoCo这种便利性,原因有两个:

(1)Android虚拟机不同服务器上的JVM,它所支持的字节码必须经过处理支持Android Dalvik等专用虚拟机,所以插桩必须在处理之前完成,即离线插桩模式。

(2)Android虚拟机没有配置JVM 配置项的机制,所以应用启动时没有机会直接配置dump输出方式。

这里我们确定了androidjacoco覆盖率是采用离线插桩的方式。

手工获取测试覆盖率

为了不修改开发的核心代码,我们可以采用通过instrumentation调起被测APP,在instrumentation activity退出时增加覆盖率的统计(不修改核心源代码)。

这里简单介绍下方法。

step1:在不修改android源码的情况下,在src/main/java 里面新增一个test目录 里面存放3个文件:FinishListener、InstrumentedActivity、JacocoInstrumentation

FinishListener源码:

public interface FinishListener { void onActivityFinished(); void dumpIntermediateCoverage(String filePath);}

InstrumentedActivity源码:

import com.netease.coverage.jacocotest1.MainActivity;public class InstrumentedActivity extends MainActivity { public FinishListener finishListener ; public void setFinishListener(FinishListener finishListener){ this.finishListener = finishListener; } @Override public void onDestroy() { if (this.finishListener !=null){ finishListener.onActivityFinished(); } super.onDestroy(); } }

JacocoInstrumentation源码:

import android.app.Activity;import android.app.Instrumentation;import android.content.Intent;import android.os.Bundle;import android.os.Looper;import android.util.Log;import java.io.File;import java.io.FileOutputStream;import java.io.IOException;import java.io.OutputStream; public class JacocoInstrumentation extends Instrumentation implements FinishListener { public static String TAG = "JacocoInstrumentation:"; private static String DEFAULT_COVERAGE_FILE_PATH = "/mnt/sdcard/coverage.ec"; private final Bundle mResults = new Bundle(); private Intent mIntent; private static final boolean LOGD = true; private boolean mCoverage = true; private String mCoverageFilePath; public JacocoInstrumentation() { } @Override public void onCreate(Bundle arguments) { Log.d(TAG, "onCreate(" + arguments + ")"); super.onCreate(arguments); DEFAULT_COVERAGE_FILE_PATH = getContext().getFilesDir().getPath().toString() + "/coverage.ec"; File file = new File(DEFAULT_COVERAGE_FILE_PATH); if (file.isFile() && file.exists()){ if (file.delete()){ System.out.println("file del successs"); }else { System.out.println("file del fail !"); } } if (!file.exists()) { try { file.createNewFile(); } catch (IOException e) { Log.d(TAG, "异常 : " + e); e.printStackTrace(); } } if (arguments != null) { mCoverageFilePath = arguments.getString("coverageFile"); } mIntent = new Intent(getTargetContext(), InstrumentedActivity.class); mIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); start(); } @Override public void onStart() { System.out.println("onStart def"); if (LOGD) Log.d(TAG, "onStart()"); super.onStart(); Looper.prepare(); InstrumentedActivity activity = (InstrumentedActivity) startActivitySync(mIntent); activity.setFinishListener(this); } private boolean getBooleanArgument(Bundle arguments, String tag) { String tagString = arguments.getString(tag); return tagString != null && Boolean.parseBoolean(tagString); } private void generateCoverageReport() { OutputStream out = null; try { out = new FileOutputStream(getCoverageFilePath(), false); Object agent = Class.forName("org.jacoco.agent.rt.RT") .getMethod("getAgent") .invoke(null); out.write((byte[]) agent.getClass().getMethod("getExecutionData", boolean.class) .invoke(agent, false)); } catch (Exception e) { Log.d(TAG, e.toString(), e); e.printStackTrace(); } finally { if (out != null) { try { out.close(); } catch (IOException e) { e.printStackTrace(); } } } } private String getCoverageFilePath() { if (mCoverageFilePath == null) { return DEFAULT_COVERAGE_FILE_PATH; } else { return mCoverageFilePath; } } private boolean setCoverageFilePath(String filePath){ if(filePath != null && filePath.length() > 0) { mCoverageFilePath = filePath; return true; } return false; } private void reportEmmaError(Exception e) { reportEmmaError("", e); } private void reportEmmaError(String hint, Exception e) { String msg = "Failed to generate emma coverage. " + hint; Log.e(TAG, msg, e); mResults.putString(Instrumentation.REPORT_KEY_STREAMRESULT, "\nError: " + msg); } @Override public void onActivityFinished() { if (LOGD) Log.d(TAG, "onActivityFinished()"); if (mCoverage) { System.out.println("onActivityFinished mCoverage true"); generateCoverageReport(); } finish(Activity.RESULT_OK, mResults); } @Override public void dumpIntermediateCoverage(String filePath){ // TODO Auto-generated method stub if(LOGD){ Log.d(TAG,"Intermidate Dump Called with file name :"+ filePath); } if(mCoverage){ if(!setCoverageFilePath(filePath)){ if(LOGD){ Log.d(TAG,"Unable to set the given file path:"+filePath+" as dump target."); } } generateCoverageReport(); setCoverageFilePath(DEFAULT_COVERAGE_FILE_PATH); } }}

step2:app module的build.gradle 增加jacoco插件和打开覆盖率统计开关

apply plugin: 'jacoco'jacoco { toolVersion = "0.7.4+"}
buildTypes { debug { /**打开覆盖率统计开关**/ testCoverageEnabled = true } release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' }}

step3:修改AndroidManifest.xml文件

1、在<application>中声明InstrumentedActivity

<activity android:label="InstrumentationActivity" android:name="com.netease.coverage.test.InstrumentedActivity" />

2、声明使用SD卡权限

<uses-permission android:name="android.permission.USE_CREDENTIALS" />

3、单独声明JacocoInstrumentation

<instrumentation android:handleProfiling="true" android:label="CoverageInstrumentation" android:name="com.netease.coverage.test.JacocoInstrumentation" android:targetPackage="com.netease.coverage.jacocotest1"/> <!-- 项目名称 -->

step4:在命令行下通过adb shell am instrument命令调起app,命令:adb shell am instrument com.qunhe.designer/com.coverage.JacocoInstrumentation

step5:拷贝手机目录的/data/data/xxx/coverage.ec文件至app工程根目录/build/outputs/code-coverage/connected下

step6:新增gradle task,修改app module的build.gradle文件

def coverageSourceDirs = [ '../app/src/main/java']task jacocoTestReport(type: JacocoReport) { group = "Reporting" description = "Generate Jacoco coverage reports after running tests." reports { xml.enabled = true html.enabled = true } classDirectories = fileTree( dir: './build/intermediates/classes/debug', excludes: ['**/R*.class', '**/*$InjectAdapter.class', '**/*$ModuleAdapter.class', '**/*$ViewInjector*.class' ]) sourceDirectories = files(coverageSourceDirs) executionData = files("$buildDir/outputs/code-coverage/connected/coverage.ec") doFirst { new File("$buildDir/intermediates/classes/").eachFileRecurse { file -> if (file.name.contains('$$')) { file.renameTo(file.path.replace('$$', '$')) } } }}


step7:在命令行执行gradle jacocoTestReport或者将AS切换至gradle试图点击jacocoTestReport

这一步需要确保第六步的dir对应的目录下有有编译后的class文件。然后执行gradle命令

step8:在app\build\reports\jacoco\jacocoTestReport\html目录下看到html报告

自动化获取测试覆盖率

上文的“手工获取测试覆盖率”在实际项目中发现存在几个弊端:

  • 每次启动app都需要通过adb命令启动instrumentation,比较麻烦

  • 覆盖率报告需要通过编译器执行gradle命令来生成,这就意味着每次测试完成,都必须将ec文件上传到本地开发环境去执行,步骤过于繁琐

因此我们针对这几点,设计了测试覆盖率统计工具2.0版本即自动化获取测试覆盖率,解决方案:

1、为什么一定要通过adb命令启动app才能获得覆盖率数据呢?

我们通过查看代码可以发现,在JacocoInstrumentation类中有这么一段代码:

当InstrumentationActivity结束时,才会将内存中的jacoco覆盖率数据dump到ec文件中。因此我们必须要通过adb启动JacocoInstrumentation,然后杀掉进程后,此时activity会结束并执行输出ec文件的相关功能。

为了解决此问题,那么ec文件的输出触发行为就不能是通过InstrumentationActivity的结束。我们采取的方式是通过触发页面上的一个按钮来执行上述操作。具体后文介绍。

2、为了解决ec文件上传到本地开发环境的繁琐步骤,我们采取的方式是通过jenkins自身提供的jacoco插件去生成覆盖率报告。具体后文介绍。

流程模块设计

流程设计:

模块设计:

数据生成及上报

step1:手机本地目录生成ec文件

具体操作是:点击app上的按钮,触发dump内存到ec文件的操作

此时覆盖率ec文件保存在手机sd卡目录下。

部分源码:

从上面的代码中可以看出,当监听到按钮点击事件后,会触发dump内存到ec文件的操作。这种方式可以避免上文提到的必须adb名启动instrumention才可以获取到覆盖率数据的弊端。

step2:触发jenkinspipeline,上报任务

点击Post按钮,自动请求http://xxx/jenkins/job/jacoco-report-general/build接口

部分源码:

import android.Manifestimport android.os.Bundleimport android.util.Base64import android.view.LayoutInflaterimport android.view.Viewimport android.view.ViewGroupimport androidx.databinding.DataBindingUtilimport com.facebook.stetho.okhttp3.StethoInterceptorimport com.karumi.dexter.Dexterimport com.karumi.dexter.PermissionTokenimport com.karumi.dexter.listener.PermissionDeniedResponseimport com.karumi.dexter.listener.PermissionGrantedResponseimport com.karumi.dexter.listener.PermissionRequestimport com.karumi.dexter.listener.single.PermissionListener import okhttp3.MediaTypeimport okhttp3.MultipartBodyimport okhttp3.OkHttpClientimport okhttp3.RequestBodyimport retrofit2.Retrofitimport retrofit2.adapter.rxjava.RxJavaCallAdapterFactoryimport rx.android.schedulers.AndroidSchedulersimport rx.schedulers.Schedulers /** * fragment for coverage test * * */class CoverageTestFragment : BaseNewFragment<BasePresenter<IBaseView>>() { companion object { private const val GIT_URL = "git地址" private const val JENKINS_USER_NAME = "jenkins_user_name" private const val JENKINS_PWD = "jenkins_pwd" } private lateinit var mBinding: FragmentCoverageTestBinding override fun inflateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { mBinding = DataBindingUtil.inflate(inflater, R.layout.fragment_coverage_test, container, false) return mBinding.root } override fun initView(view: View) { val builder = OkHttpClient.Builder() .addNetworkInterceptor(StethoInterceptor()) .build() val api = Retrofit.Builder() .baseUrl("jenkins地址") .client(builder) .addConverterFactory(StringConverterFactory()) .addCallAdapterFactory(RxJavaCallAdapterFactory.create()) .build() .create(TestApi::class.java) mBinding.gitHash.text = BuildConfig.GIT_HASH mBinding.gitUrl.setText(GIT_URL) updateEcFileView() mBinding.deleteExec.setOnClickListener { TestUtils.deleteEcFile() updateEcFileView() } SharedPreferencesUtil.getString(context, JENKINS_USER_NAME)?.let { mBinding.userName.setText(it) } SharedPreferencesUtil.getString(context, JENKINS_PWD)?.let { mBinding.password.setText(it) } mBinding.post.setOnClickListener { if (checkParams()) { mBinding.loading.show() val userName = mBinding.userName.text.toString() val password = mBinding.password.text.toString() val authorization = "Basic ${getBase64String("$userName:$password")}" val ecFile = TestUtils.getEcFile() val requestBody = MultipartBody.Builder() .setType(MultipartBody.FORM) .addFormDataPart("json", covertParamString()) .addFormDataPart("name", "execFile") .addFormDataPart( "file0", ecFile.name, RequestBody.create( MediaType.parse("application/octet-stream"), ecFile ) ) // TODO: 现在没有 mergerFile 先传空的数据 后面有了再加上 .addFormDataPart("name", "mergerFile") .addFormDataPart( "file1", "", RequestBody.create( MediaType.parse("application/octet-stream"), "" ) ) .build() api.postCoverageParams(authorization, requestBody) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe({ QhLog.d(it) showToast("请求成功") mBinding.loading.dismiss() }, { it.printStackTrace() showToast("请求失败 请检查参数") mBinding.loading.dismiss() }) // 保存用户名密码 SharedPreferencesUtil.setString( context, JENKINS_USER_NAME, userName ) SharedPreferencesUtil.setString(context, JENKINS_PWD, password) } } mBinding.generateEcFile.setOnClickListener { TestUtils.generateEcFile() updateEcFileView() } checkPermission() } private fun updateEcFileView() { if (TestUtils.existEcFile()) { mBinding.execFilePath.text = TestUtils.getEcFile().absolutePath } else { mBinding.execFilePath.setText(R.string.coverage_test_exec_file_miss) } } private fun checkParams(): Boolean { if (mBinding.gitUrl.text.toString().isBlank()) { mBinding.gitUrlLayout.error = "git url 不能为空" return false } if (mBinding.userName.text.toString().isBlank()) { mBinding.userNameLayout.error = "用户名不能为空" return false } if (mBinding.password.text.toString().isBlank()) { mBinding.passwordLayout.error = "密码不能为空" return false } if (!TestUtils.existEcFile()) { showToast("ec 文件未生成") return false } return mBinding.gitUrl.text.toString().isNotBlank() && TestUtils.existEcFile() } private fun getBase64String(str: String): String { return Base64.encodeToString(str.toByteArray(), Base64.NO_WRAP) } private fun covertParamString(): String { val list = ArrayList<Map<String, String>>(6) list.add(createMap("execFile", null, "file0")) list.add(createMap("mergerFile", null, "file1")) list.add(createMap("gitUrl", mBinding.gitUrl.text.toString(), null)) list.add(createMap("branch", "", null)) list.add(createMap("commitHash", mBinding.gitHash.text.toString(), null)) // 这里参数直接写死 val map = HashMap<String, String>() map["name"] = "gitCredential" map["credentialType"] = "" map["required"] = "false" list.add(map) val paramMap = HashMap<String, List<Map<String, String>>>() paramMap["parameter"] = list return ObjectMapperSingleton.getInstance().writeValueAsString(paramMap) } private fun createMap(name: String, value: String?, file: String?): Map<String, String> { val map = HashMap<String, String>() map["name"] = name if (value != null) { map["value"] = value } else { map["file"] = file!! } return map } private fun checkPermission() { if (!Dexter.isRequestOngoing()) { Dexter.checkPermission(object : PermissionListener { override fun onPermissionGranted(response: PermissionGrantedResponse?) { } override fun onPermissionRationaleShouldBeShown( permission: PermissionRequest?, token: PermissionToken? ) { token?.continuePermissionRequest() } override fun onPermissionDenied(response: PermissionDeniedResponse?) { showToast("授权失败") checkPermission() } }, Manifest.permission.WRITE_EXTERNAL_STORAGE) } }}


从上面代码的“mBinding.post.setOnClickListener”方法中可以看出,当监听到“post”按钮点击事件后,会自动触发jenkinspipeline,去上报任务并生成报告。这种方式可以避免上文提到的本地开发环境生成报告的繁琐步骤。

报告生成

当jenkinspipeline被触发后,会自动生成报告。以下是触发build后的运行脚本:

pipeline { agent { label "android-jacoco-slave" } parameters { file(description: 'execFile', name: 'execFile') file(description: 'mergerFile', name: 'mergerFile') string(defaultValue: "git地址", description: 'gitUrl', name: 'gitUrl') string(defaultValue: "分支", description: 'branch', name: 'branch') string(defaultValue: "commithash", description: 'commitHash', name: 'commitHash') credentials(defaultValue: "gitCredential的值", description: 'gitCredential', name: 'gitCredential') } stages { stage('clean out') { steps { cleanWs() } } stage('checkout') { steps { script { if("${branch}"){ checkout([$class: 'GitSCM', branches: [[name: '*/${branch}']],userRemoteConfigs: [[credentialsId: '${gitCredential}', url: '${gitUrl}']]]) }else{ checkout([$class: 'GitSCM', branches: [[name: '${commitHash}']],userRemoteConfigs: [[credentialsId: '${gitCredential}', url: '${gitUrl}']]]) } echo "${execFile}" } // script { // println("check start...") // git branch:'${branch}', credentialsId: '${gitCredential}', url: '${gitUrl}' // } } } stage('gernal exec') { steps { script { library "jenkinsci-unstashParam-library" def execFile = unstashParam "execFile" def commitShortHash = commitHash[0..7] sh "mkdir classes" sh "cp -r /Users/git2/designerclass/${commitShortHash}/* classes/" sh "jar cvf classes.jar classes/" sh "ls" sh "pwd" if("${mergerFile}"){ sh "ls" def mergerFile = unstashParam "mergerFile" sh "cat ${mergerFile}" sh "curl 存储jar包地址 -o jacococli.jar" sh "cat ${execFile}" sh "cat ${mergerFile}" sh "java -jar jacococli.jar merge ${execFile} ${mergerFile} --destfile all.ec" sh "ls" sh "cat all.ec" if (fileExists("${execFile}")) { sh "rm ${execFile}" } else { println "${execFile} not found" } if (fileExists("${mergerFile}")) { sh "rm ${mergerFile}" } else { println "${mergerFile} not found" } } } println("------------------------------") } } stage('Build') { steps { // sh "mvn clean" // sh "mvn clean package -U" // echo "${execFile}" println("------------------------------") } } stage('Jacoco report') { steps { sh "ls" sh "pwd" jacoco( execPattern: '**/**.ec', classPattern: '**/classes', sourcePattern: '**/app/src/main/java', exclusionPattern: '**/*$InjectAdapter.class,**/*$ModuleAdapter.class,**/*$ViewInjector*.class,**/*Binding.class,**/*BindingImpl.class' ) } }//这一步是将jenkins的覆盖率数据传给kuafu平台,可以忽略 stage('after generate report') { steps { echo "${BUILD_ID}" script { def branchInfo = "null" def commitId = "null" if("${branch}"){ branchInfo = "${branch}" } if("${commitHash}"){ commitId = "${commitHash}" } def result = sh(script:"curl http://kuafu.qunhequnhe.com/api -X POST -d '{\"repo\": \"git地址\", \"serviceUuid\":\"项目名称\", \"branch\":\"${branchInfo}\", \"env\":\"dev\" , \"userName\":\"appJenkins\", \"tag\":\"null\", \"vip\":\"\", \"imageName\":\"null\", \"commitId\":\"${commitId}\"}' -H 'Content-Type:application/json' ", returnStdout: true).trim() echo "${result}" def taskId = null try { def resJson = readJSON text: "${result}" taskId = "${resJson.data.taskId}" } catch (e){ echo "Error: " + e.toString() } echo "${taskId}" if ("${taskId}") { sh "curl -XPOST http://kuafu.qunhequnhe.com/api -F 'file=@${execFile}' -H " sh "curl -XPOST http://kuafu.qunhequnhe.com/api -F 'file=@classes.jar' -H " } } } } stage('clear unuseful class') { steps { script { sh "pwd" def path="/Users/git2/designerclass" sh "ls ${path}" def result = sh(script: "find ${path} -maxdepth 1 -mtime +10 -type d", returnStdout: true).trim() echo "${result}" sh "find ${path} -maxdepth 1 -mtime +10 -type d -exec rm -Rf {} \\;" echo "clear ${path} 10 days ago' files done" } } } } post { always { script { sh "date" sh "ls" sh "pwd" } } }}


构建面板有以下参数,现在具体介绍下:

  • execFile:本地上传一个ec文件

  • mergerFile:默认不上传文件时,即生成execfile参数对应的ec文件覆盖率报告;若同时上传了execfile参数对应的ec1,mergerfile上传了对应ec2,那么脚本会先将ec1和ec2merge成all.ec文件,然后对all.ec生成覆盖率报告。

  • gitUrl:app repo地址

  • branch: 填写当前测试包的源码所对应的repo的分支,branch和commitHash仅填一个,建议填写hash

  • commitHash:填写当前测试包的源码所对应的git 提交hash值,branch和commitHash仅填一个。建议填写hash值,因为一旦branch提交了新的代码,那源码就和ec文件不匹配了。而hash值是唯一的。

  • gitCredential:认证账号密码,不用特意选择,默认全局通用账号就可以

  • build按钮

stage('gernal exec') 介绍

  • execFile:本地上传一个ec文件

  • mergerFile:默认不上传文件时,即生成execfile参数对应的ec文件覆盖率报告;若同时上传了execfile参数对应的ec1,mergerfile上传了对应ec2,那么脚本会先将ec1和ec2merge成all.ec文件,然后对all.ec生成覆盖率报告。
    主要是根据以上两个参数来判断是否需要mergec文件。
    由于酷家乐这边app的ci服务器打包app时会自动生成class文件。所以我们把每次生成的class文件copy到/designerclass/{gitcommithash}文件下,gitcommithash是git提交时的hash值。那么/designerclass/{gitcommithash}下可能会有hash1文件夹,hash2文件夹。然后通过参数commitHash取对应的hash文件夹再copy到/Jenkins/class文件夹下

stage('after generate report')介绍

触发覆盖率平台,把覆盖率相关指标信息传给kuafu.qunhequnhe.com平台。kuafu平台具体会在页面展示中介绍。

stage('clear unuseful class')介绍

由于每次打包都会生成一个/designerclass/{gitcommithash}文件夹,里面包含class文件。一个迭代结束后,那么很多文件势必会无用或已过期。因此这里做了一次删除操作,如果是10天前创建的文件,我们就把他删除掉。

find ${path} -maxdepth 1 -mtime +10 -type d -exec rm -Rf {} \\;意思是,删除designerclass文件下的10天前修改的子文件夹。

页面展示

当pipeline执行完成后,jenkins会自动生成一个覆盖率报告:

但是我们需要一个统一的平台来展示每一次报告的指标信息,如环境、代码分支、执行时间、覆盖/未覆盖行数、覆盖率等。酷家乐内部提供了一个覆盖率平台来统一展示,即上文提到的kuafu.qunhequnhe.com。kuafu平台是一个统一的覆盖率展示平台。它收集了各个业务线需要度量的环境和分支信息等。

全量覆盖率展示

业务实践

由于android覆盖率目前仅做了全量,尚未做到增量情况。所以报告提供的信息不够明显。后面讲解下怎么看一份全量的覆盖率报告。

1.首先需要有一份已经完成新功能测试(回归测试可以先不考虑)的报告

报告:

只能看出全量覆盖了多少代码,不能看出本次改动的代码是否覆盖。

而覆盖率的意义就在于确认核心代码是否被测试用例覆盖,以补充测试用例完善测试场景。

因此我们需要确认本次改动的核心代码和需求是否被覆盖到。那么首先我们就需要拿到改动代码的范围,下文介绍。

2.获取当前版本与之前老版本的改动代码

因为目前我们没做增量覆盖率,因此还是手动获取改动代码。这可以借助于gitlab自身提供的compare。

首先,当前测试的app是5.5.0,测试分支是release/release-5.5.0,老版本是5.4.0,分支是release/release-5.4.0。

那么这里source就填写测试分支,target填写老版本分支。然后点击compare按钮进行比对。此时可以看到所有的commit以及代码改动:

3.获取核心需求的代码文件并进行排查

因为本期核心需求是xx功能,因此我们主要看下该需求的覆盖情况即可。(无法全部核对,因为未支持增量覆盖率,如果一一排查很费时间。)

根据第二步的代码提交相关信息,发现核心的文件如下:

  • 新增文件:app/src/main/java/xx/xx.kt

          该文件的主要功能是将app的三方信息发送给头条,进行账号绑定。

          查看覆盖率报告:


 结果:整个文件没有覆盖到。

         收益:补充3条用例:app登录qq/微信/微博账号,分享方案到头条

  • 新增了文件:app/src/main/java/xx/xx.kt
    该功能主要是方案的各个分享渠道,包括分享到qq、微信等。
    查看覆盖率报告:

  • 结果:发现除了头条外,其他的分享渠道都没覆盖到。但是本期需求只有头条是新增的渠道,其他都是老功能,理论上并不需要覆盖。可是这个文件却是新增的。和开发沟通过,开发解释是将以前的代码迁移到了新文件(未告知测试)。

          收益:补充以下用例:需要回归:分享投稿方案到所有渠道。

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存